如果你在整理一個班級的成績單。有些學生數學拿 80 分,有些拿 0 分;還有人因為尚未交卷,成績欄是空白。若把全部空白都填成 0,就會把「還不知道」誤認為「已測量且是零」。
在變異分析中,情況也一樣。昨天 Day 22 我們看懂了 QC 為什麼會依序檢查 FILTER、BED、REF 和 QUAL。今天只挑 Day 21 真正通過教學 QC 的三筆候選,把人工分數整理成機器可以讀、研究者可以查的標註表。重點不是哪筆比較危險,而是:每個欄位代表什麼?資料從哪裡來?沒有分數時怎麼說清楚?
今天還會回到最初的學習方法:先用 Google AI Studio 想像看得見的 MVP,再用 Python 拆解面板背後的資料結構。
Day 21 的合成 VCF 有七列記錄,其中一列含兩個 ALT,展開後共有 8 筆變異候選。Day 22 逐筆追蹤 QC,結果沒有改變:
| QC 狀態 | 筆數 | 表示什麼? |
|---|---|---|
SELECTED |
3 | 通過人工定義的教學資料檢查,才進標註表 |
REVIEW_REQUIRED |
2 | 尚未正規化的 indel 或缺 QUAL,需要人工處理 |
REJECTED |
3 | 在某道檢查已被排除,仍保留理由 |
通過 QC 的三筆,依 Day 21 原本的人工分數表整理如下:
| 人工變異 | synthetic_score |
score_status |
review_status |
|---|---|---|---|
chr1:100:A>C |
0.42 |
SYNTHETIC_SCORE |
REVIEW_REQUIRED |
chr1:110:A>G |
-0.17 |
SYNTHETIC_SCORE |
REVIEW_REQUIRED |
chr1:110:A>C |
null |
MISSING_SCORE |
REVIEW_REQUIRED |
這裡的 0.42、−0.17 只是在教你資料聯接,不是 AlphaGenome AVI,也不具致病性尺度。 三筆全部需要人工審閱;SELECTED 不是高風險標籤,更不是 ACMG/AMP 分類。
0.0 跟 None 不是同一回事
Python 裡,0.0 是已取得的數字;None 代表這個欄位沒有數值。轉成 JSON 後,None 才會成為 null。程式故意使用兩個欄位:
synthetic_score:保留數值或 null。score_status:明確註記 SYNTHETIC_SCORE 或 MISSING_SCORE。score_status=MISSING_SCORE 保存,不能依靠空白格猜測生物學意義。
初學者很容易直接在 VS Code 面對空白檔案不知從何開始。你可以先開啟 Google AI Studio 的 Build Mode,貼上教學包內的 AI_STUDIO_DAY23_PROMPT.txt;它要求生成一個只有人工資料的標註面板:摘要卡、QC 表、三筆選入候選、缺值提示,以及 JSON/CSV 欄位預覽。
畫面拆回 Python 時,可以對應到下面的模組:
| MVP 畫面 | Python 函式 | 學到什麼 |
|---|---|---|
| 來源 QC 統計卡 | Day 21 build_review_packet() |
為什麼只有三筆進標註 |
| 空白與零分的比較卡 | score_state() |
None、0.0、null 的差異 |
| 整齊的標註資料表 | annotation_schema() |
schema_version、欄位契約與人工審閱 |
| JSON/CSV 下載區 | emit_artifacts() |
同一資料的不同輸出格式 |
| 瀏覽器上的結果面板 | render_html() |
HTML 安全顯示與資料來源說明 |
注意:AI Studio 產生的介面可能是 React/JavaScript;Get Code 不會自動把它變成 Python。這裡的「逆向拆解」是照著 UI 的資料需求,自己用 Python 重建同一套輸入、處理與輸出邏輯。
我們將 Day 21 的 review_packet 映射成 Day 23 的固定表格,使用 schema_version=day23.synthetic.annotation.v1。欄位包括:
| 欄位群 | 欄位 | 為什麼保留? |
|---|---|---|
| 變異識別 | variant_id、chrom、pos_1based、ref、alt |
確定同一筆人工候選,不混用座標 |
| QC 判斷 | qc_status、qc_reason |
讓下一位研究者知道選入原因 |
| 標註資料 | synthetic_score、score_status、score_source |
避免把缺值、人工值與真正模型輸出混淆 |
| 人工審閱 | review_status |
保留 REVIEW_REQUIRED,不以軟體 PASS 代替判讀 |
| 可追溯性 | source_line、input_provenance、SHA-256 |
能找到原 VCF 行與來源檔案內容版本 |
SHA-256 是內容一致性指紋,無法自行證明原始來源為真,也不代表具備臨床有效性。 TOY_NOT_GRCH38 表示我們沒有使用真正的人類參考基因組;本例原本的 BED 檢查也只是 POS−1 錨點規則,不是完整 indel 區間分析。
這一篇刻意直接匯入、重用已通過測試的 day21_offline_vcf_review_lab.py,不重新發明一套 VCF/BED/REF/QUAL 規則。程式先檢查 Day 21 的輸入是否明確標記為人工合成,再把三筆 SELECTED 變成固定 schema。檢查失敗時會拒絕來源不明、數量不一致或冒用 LIVE/正式 AVI 的資料。
把教學包完整解壓至同一資料夾。必須同時存在 Day 21 原程式與四份人工資料:
Day23 Lab/
├── day23_offline_annotation_lab.py # 本篇完整 Python
├── test_day23_offline_annotation_lab.py # 本篇測試
├── day21_offline_vcf_review_lab.py # 原樣保留的 Day 21 模組
├── day21_synthetic.vcf # 仍是人工 VCF
├── day21_toy_reference.json # 200 個 A 的人工參考
├── day21_synthetic_regions.bed # 人工 BED
├── day21_synthetic_scores.json # 兩個人工分數
└── day21_review_packet_reference.json # 可選:上篇生成的離線 JSON
以下為完整的 day23_offline_annotation_lab.py,只依賴 Python 3.12 標準函式庫與同目錄的 Day 21 模組,不需要 API Key。
"""Day 23 | OFFLINE synthetic VCF annotation schema and missing-value lesson.
Reuses Day21's ORIGINAL synthetic parser/QC and four byte-identical fixtures.
Does not load an AlphaGenome SDK, use a key, make a request, or interpret disease.
Python 3.12+ standard library only. NOT a clinical or production VCF engine.
"""
from __future__ import annotations
import argparse
import csv
import hashlib
from html import escape
import json
import math
from pathlib import Path
import re
from day21_offline_vcf_review_lab import (
ASSEMBLY, QC_ORDER, VARIANT_RE, build_review_packet,
load_toy_reference, load_toy_scores, parse_toy_bed,
)
EVIDENCE_CLASS = "SYNTHETIC_EDUCATIONAL_DEMO"
SCHEMA_VERSION = "day23.synthetic.annotation.v1"
ANNOTATION_FIELDS = (
"variant_id", "chrom", "pos_1based", "ref", "alt", "qc_status",
"qc_reason", "synthetic_score", "score_status", "score_source",
"review_status", "source_line",
)
SHA256_RE = re.compile(r"[a-f0-9]{64}\Z")
EXPECTED_QC_ORDER = ("FILTER", "BED", "REF", "INDEL", "MISSING_QUAL", "LOW_QUAL")
ALLOWED_QC = {"SELECTED", "REVIEW_REQUIRED", "REJECTED"}
REASONS_BY_STATUS = {
"SELECTED": {"TEACHING_QC_PASS"},
"REVIEW_REQUIRED": {"INDEL_NOT_NORMALIZED", "MISSING_QUAL"},
"REJECTED": {"FILTER_NOT_PASS", "OUTSIDE_TEACHING_BED", "REF_MISMATCH", "LOW_QUAL"},
}
def score_state(value):
"""None means absent; 0.0 is a PRESENT numeric teaching value."""
if value is None:
return "MISSING_SCORE"
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
raise ValueError("Synthetic score must be finite numeric or null")
return "SYNTHETIC_SCORE"
def split_variant(variant_id):
if not isinstance(variant_id, str) or VARIANT_RE.fullmatch(variant_id) is None:
raise ValueError("Invalid artificial variant ID")
chrom, pos, alleles = variant_id.split(":")
ref, alt = alleles.split(">")
if ref == alt:
raise ValueError("REF and ALT must differ")
return chrom, int(pos), ref, alt
def validate_day21_packet(packet):
"""Accept only the narrow, explicit Day21 synthetic contract, never live/patient data."""
if not isinstance(packet, dict):
raise ValueError("Day21 packet must be a JSON object")
if (packet.get("evidence_class") != EVIDENCE_CLASS
or packet.get("mode") != "OFFLINE_ONLY"
or packet.get("assembly") != ASSEMBLY
or packet.get("score_source") != "SYNTHETIC_FIXTURE_ONLY"
or packet.get("atlas_query_performed") is not False
or packet.get("clinical_interpretation") is not False
or packet.get("real_api_calls_this_run") != 0):
raise ValueError("Refusing input not explicitly marked as offline synthetic teaching data")
if tuple(QC_ORDER) != EXPECTED_QC_ORDER or packet.get("qc_order") != list(EXPECTED_QC_ORDER):
raise ValueError("QC precedence differs from validated Day21")
qc_rows, review_rows = packet.get("qc_rows"), packet.get("review_packet")
if not isinstance(qc_rows, list) or not qc_rows or not isinstance(review_rows, list):
raise ValueError("Day21 packet requires QC and annotation lists")
qc_by_id = {}
for row in qc_rows:
if not isinstance(row, dict):
raise ValueError("Malformed QC row")
variant_id = row.get("variant_id")
split_variant(variant_id)
if variant_id in qc_by_id or row.get("qc_status") not in ALLOWED_QC:
raise ValueError("Duplicate QC variant or invalid status")
if row.get("reason") not in REASONS_BY_STATUS[row["qc_status"]]:
raise ValueError("QC reason is not valid for its status")
qual = row.get("qual")
if qual is not None and (isinstance(qual, bool) or not isinstance(qual, (int, float)) or not math.isfinite(qual) or qual < 0):
raise ValueError("Invalid QUAL in QC row")
if (not isinstance(row.get("filter"), str) or not row["filter"]
or not isinstance(row.get("source_line"), int)
or isinstance(row.get("source_line"), bool)
or row["source_line"] < 1):
raise ValueError("QC filter and original source line required")
qc_by_id[variant_id] = row
counts = {
"SELECTED": sum(r["qc_status"] == "SELECTED" for r in qc_rows),
"REVIEW_REQUIRED": sum(r["qc_status"] == "REVIEW_REQUIRED" for r in qc_rows),
"REJECTED": sum(r["qc_status"] == "REJECTED" for r in qc_rows),
}
if any(packet.get(field) != expected for field, expected in (
("candidate_count", len(qc_rows)), ("selected_count", counts["SELECTED"]),
("review_required_qc_count", counts["REVIEW_REQUIRED"]),
("rejected_count", counts["REJECTED"]),
)):
raise ValueError("QC count contract mismatch")
reviewed = set()
missing = 0
for row in review_rows:
if not isinstance(row, dict):
raise ValueError("Malformed annotation row")
variant_id = row.get("variant_id")
split_variant(variant_id)
if variant_id in reviewed or variant_id not in qc_by_id:
raise ValueError("Duplicate or unmatched annotation")
if qc_by_id[variant_id]["qc_status"] != "SELECTED":
raise ValueError("Non-selected variants must not be annotated")
value = row.get("synthetic_score")
status = score_state(value)
if (row.get("score_status") != status
or row.get("review_status") != "REVIEW_REQUIRED"
or row.get("evidence_class") != "SYNTHETIC_FIXTURE"):
raise ValueError("Annotation status or source is inconsistent")
missing += value is None
reviewed.add(variant_id)
if (reviewed != {vid for vid, row in qc_by_id.items() if row["qc_status"] == "SELECTED"}
or packet.get("missing_score_count") != missing):
raise ValueError("Missing, extra, or wrongly counted annotations")
return qc_by_id
def annotation_schema(packet, input_provenance):
"""Transform verified Day21 rows into Day23's explicit analysis-ready schema."""
qc_by_id = validate_day21_packet(packet)
if not isinstance(input_provenance, dict) or not input_provenance:
raise ValueError("Input provenance is required")
for name, record in input_provenance.items():
if not isinstance(name, str) or not isinstance(record, dict):
raise ValueError("Malformed provenance")
if (not isinstance(record.get("filename"), str)
or not record["filename"] or "/" in record["filename"] or "\\" in record["filename"]
or not isinstance(record.get("sha256"), str)
or SHA256_RE.fullmatch(record["sha256"]) is None):
raise ValueError("Each provenance record needs basename and SHA-256")
annotations = []
for row in packet["review_packet"]:
variant_id = row["variant_id"]
chrom, pos, ref, alt = split_variant(variant_id)
qc = qc_by_id[variant_id]
annotations.append({
"variant_id": variant_id,
"chrom": chrom,
"pos_1based": pos,
"ref": ref,
"alt": alt,
"qc_status": "SELECTED",
"qc_reason": qc["reason"],
"synthetic_score": row["synthetic_score"],
"score_status": row["score_status"],
"score_source": "SYNTHETIC_FIXTURE_ONLY",
"review_status": "REVIEW_REQUIRED",
"source_line": qc["source_line"],
})
if len(annotations) != packet["selected_count"]:
raise ValueError("Day23 annotation count mismatch")
return {
"schema_version": SCHEMA_VERSION,
"evidence_class": EVIDENCE_CLASS,
"mode": "OFFLINE_ONLY",
"assembly": ASSEMBLY,
"annotation_columns": list(ANNOTATION_FIELDS),
"candidate_count": packet["candidate_count"],
"selected_count": packet["selected_count"],
"review_required_qc_count": packet["review_required_qc_count"],
"rejected_count": packet["rejected_count"],
"annotated_count": sum(r["score_status"] == "SYNTHETIC_SCORE" for r in annotations),
"missing_score_count": sum(r["score_status"] == "MISSING_SCORE" for r in annotations),
"qc_order": list(QC_ORDER),
"qc_rows": packet["qc_rows"],
"annotations": annotations,
"missing_data_policy": "JSON null + MISSING_SCORE; blank CSV numeric cell + MISSING_SCORE; never substitute zero",
"input_provenance": input_provenance,
"pipeline_source": "REUSED_UNMODIFIED_DAY21_SYNTHETIC_QC",
"official_avi": False,
"atlas_query_performed": False,
"clinical_interpretation": False,
"real_api_calls_this_run": 0,
}
def render_html(report):
"""Create a standalone HTML table; escape all data-derived labels."""
def h(value):
return escape("null" if value is None else str(value), quote=True)
annotation_rows = "\n".join(
"<tr>" + "".join(f"<td>{h(row[column])}</td>" for column in
("variant_id", "qc_status", "synthetic_score", "score_status", "score_source", "review_status")) + "</tr>"
for row in report["annotations"]
)
qc_rows = "\n".join(
"<tr>" + "".join(f"<td>{h(row.get(column))}</td>" for column in
("variant_id", "qc_status", "reason")) + "</tr>"
for row in report["qc_rows"]
)
return f'''<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Day 23 | Synthetic Annotation Schema</title>
<style>:root{{font:16px/1.55 system-ui,"Noto Sans TC",sans-serif;color:#2e3649;background:#f1f1f2}}
body{{margin:auto;max-width:1140px;padding:30px 18px 65px}}h1{{font-size:1.85rem}}
.warning{{background:#fff1de;border-left:5px solid #b8743e;padding:15px;border-radius:8px}}
.stats{{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}}
.stats div,section{{background:white;border:1px solid #dfdfdf;border-radius:12px;padding:16px;margin-top:18px}}
.num{{font-size:2rem;font-weight:800;color:#286683}}.label{{font-size:.85rem;color:#697386}}
table{{width:100%;border-collapse:collapse}}th,td{{text-align:left;padding:10px;border-bottom:1px solid #eaeaea}}
th{{background:#e9f0f5}}section{{overflow-x:auto}}.missing{{color:#a83b43}}
footer{{font-size:.85rem;color:#687386;margin-top:25px}}</style></head><body>
<h1>Day 23|從缺失值到結構化標註欄位</h1>
<div class="warning"><strong>SYNTHETIC · OFFLINE_ONLY · TOY_NOT_GRCH38</strong><br>
所有分數均為人工教學值,不是 AlphaGenome AVI,沒有患者資料或真實 API 呼叫。所有 SELECTED 仍待研究人工審閱。</div>
<div class="stats"><div><span class="num">{report['candidate_count']}</span><br><span class="label">候選</span></div>
<div><span class="num">{report['selected_count']}</span><br><span class="label">通過教學 QC</span></div>
<div><span class="num">{report['annotated_count']}</span><br><span class="label">有人工分數</span></div>
<div><span class="num">{report['missing_score_count']}</span><br><span class="label">分數缺值</span></div></div>
<section><h2>標註表|一筆候選=一列資料</h2><p>請注意 <code>synthetic_score=null</code> 表示缺分數,並非 0;分數正負號沒有臨床風險意義。</p>
<table><thead><tr><th>Variant</th><th>QC</th><th>人工分數</th><th>分數狀態</th><th>來源</th><th>審閱</th></tr></thead><tbody>{annotation_rows}</tbody></table></section>
<section><h2>全部 QC 紀錄(保留失敗原因)</h2><table><thead><tr><th>Variant</th><th>QC Status</th><th>Reason</th></tr></thead><tbody>{qc_rows}</tbody></table></section>
<footer>schema_version: {h(report['schema_version'])} · input provenance SHA-256 另見 JSON · official_avi=false · real_api_calls_this_run=0 · 研究教育用途。</footer></body></html>'''
def emit_artifacts(report, output_dir):
"""Write deterministic JSON/CSV/HTML without external services."""
folder = Path(output_dir)
folder.mkdir(parents=True, exist_ok=True)
json_file = folder / "day23_annotation_report.json"
csv_file = folder / "day23_annotation_table.csv"
html_file = folder / "day23_annotation_panel.html"
json_file.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2, allow_nan=False) + "\n", encoding="utf-8")
with csv_file.open("w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=ANNOTATION_FIELDS, extrasaction="raise")
writer.writeheader()
writer.writerows(report["annotations"])
html_file.write_text(render_html(report), encoding="utf-8")
return {"status": "PASS", "mode": "OFFLINE_ONLY",
"candidate_count": report["candidate_count"],
"selected_count": report["selected_count"],
"annotated_count": report["annotated_count"],
"missing_score_count": report["missing_score_count"],
"json_file": str(json_file), "csv_file": str(csv_file), "html_file": str(html_file),
"real_api_calls_this_run": 0}
def execute_offline(vcf, reference, regions, scores, output_dir, input_day21=None):
if input_day21 is not None:
path = Path(input_day21)
packet = json.loads(path.read_text(encoding="utf-8"))
provenance = {"day21_review_packet": {
"filename": path.name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
}}
else:
paths = {"vcf": Path(vcf), "reference": Path(reference),
"regions": Path(regions), "scores": Path(scores)}
data = {key: path.read_text(encoding="utf-8") for key, path in paths.items()}
packet = build_review_packet(
data["vcf"], load_toy_reference(data["reference"]),
parse_toy_bed(data["regions"]), load_toy_scores(data["scores"])
)
provenance = {
key: {"filename": path.name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
for key, path in paths.items()
}
return emit_artifacts(annotation_schema(packet, provenance), output_dir)
def main(argv=None):
p = argparse.ArgumentParser(description="Day23 offline synthetic annotation and schema lab")
p.add_argument("--mode", choices=["offline"], required=True, help="LIVE is intentionally disabled")
p.add_argument("--input-day21", help="Optional prior Day21 synthetic JSON; excludes non-synthetic data")
p.add_argument("--vcf", default="day21_synthetic.vcf")
p.add_argument("--reference", default="day21_toy_reference.json")
p.add_argument("--regions", default="day21_synthetic_regions.bed")
p.add_argument("--scores", default="day21_synthetic_scores.json")
p.add_argument("--output-dir", default="day23_output")
args = p.parse_args(argv)
result = execute_offline(args.vcf, args.reference, args.regions, args.scores,
args.output_dir, input_day21=args.input_day21)
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
return result
if __name__ == "__main__":
main()
先在 Windows Downloads 取得教學 ZIP,再使用 Python 內建的 zipfile 解壓,不需要另裝 unzip:
cd /mnt/c/Users/user/Downloads
mkdir -p ~/alphagenome-day23-qa
python3.12 -m zipfile -e AlphaGenome_Day23_Annotation_Schema_API_Bridge_Author_Review_Pack.zip ~/alphagenome-day23-qa
cd ~/alphagenome-day23-qa
python3.12 -m unittest -v test_day23_offline_annotation_lab
python3.12 day23_offline_annotation_lab.py --mode offline --output-dir day23_output
主程式會在 day23_output/ 生成三個檔案:day23_annotation_report.json(完整資料契約與全部八筆 QC 原因)、day23_annotation_table.csv(三筆選入候選)和 day23_annotation_panel.html(瀏覽器可開啟的人工結果面板)。
下列為新教學程式實際執行的 stdout,Python 3.12 本機測試仍待你重跑;本例從未呼叫真實 AlphaGenome API。
{"annotated_count": 2, "candidate_count": 8, "csv_file": "day23_output/day23_annotation_table.csv", "html_file": "day23_output/day23_annotation_panel.html", "json_file": "day23_output/day23_annotation_report.json", "missing_score_count": 1, "mode": "OFFLINE_ONLY", "real_api_calls_this_run": 0, "selected_count": 3, "status": "PASS"}
CSV 內容中的最後一筆是:
chr1:110:A>C,chr1,110,A,C,SELECTED,TEACHING_QC_PASS,,MISSING_SCORE,SYNTHETIC_FIXTURE_ONLY,REVIEW_REQUIRED,6
請注意兩個逗號之間的空白數值欄。這表示缺少分數,而不是得分為零。
這個指令只比較欄位類型,不更動上面的八筆教學資料:
python3.12 -c 'from day23_offline_annotation_lab import score_state; print("0.0:", score_state(0.0)); print("None:", score_state(None))'
預期:
0.0: SYNTHETIC_SCORE
None: MISSING_SCORE
如果你已完成 Day 21,也可以把那天產生的 day21_review_packet.json 放在相同資料夾,再執行:
python3.12 day23_offline_annotation_lab.py --mode offline --input-day21 day21_review_packet.json --output-dir day23_from_day21
教學 ZIP 另附 day21_review_packet_reference.json,供尚未保留 Day 21 輸出的讀者練習。輸入仍必須帶有 OFFLINE_ONLY、SYNTHETIC_EDUCATIONAL_DEMO、TOY_NOT_GRCH38 等標記;程式會拒絕未經驗證的 LIVE/臨床報告。這不是正式醫療資料解析器。
這篇的 synthetic_score 不等於 Atlas 的 AVI。真實 API 回傳可能有不同的 scorer 名稱、AnnData 結構、模型與版本 metadata。若日後要接官方 query_variant(),應另建專用轉換器,保留 source_type、scorer、單位、版本、ontology、reference build 和 evidence_class,不能把官方 AVI 直接塞進 synthetic_score 假裝已完成相容性測試。
先前 Reboot 歷史查詢有真實 Atlas 衍生資料,但那是另一個歷史證據層,不是本篇八筆虛構 chr1 候選的結果。本篇沒有 LIVE 模式;之後如要驗證官方介面,須另行取得明確授權。
null,不是把缺值填零。score_status。schema_version、input_provenance、score_source、review_status。
SYNTHETIC EDUCATIONAL DEMO/OFFLINE_ONLY。本篇重用 Day 21 同一套人工 VCF、人工參考基因組、BED 和兩個人工數值;QC 是為入門教學選定的固定順序,沒有完成完整 VCF/indel/真實參考 FASTA/群體頻率檢查。
53 項單元測試僅驗證解析、重用 QC、缺值契約、CSV/HTML/JSON、欄位結構與拒絕 LIVE 等本地軟體行為,不是模型分子效應或臨床有效性測試。不處理真實患者資料、不呼叫 AlphaGenome/Gemini/PubMed API,也沒有自動 ACMG/AMP 判定。所有 review_status 保留 REVIEW_REQUIRED。
明天 Day 24,我們將把 REF 與 ALT 的人工軌跡並排,算出兩條訊號的差值。知道資料欄位如何設計後,下一步要學會問:「訊號變化發生在哪裡?這個差分能支持什麼假說,又還不能證明什麼?」